feat: ship per-arch standalone binaries on each release#73
feat: ship per-arch standalone binaries on each release#73ja-818 wants to merge 2 commits intostripe:mainfrom
Conversation
Adds a release-binaries workflow that produces single-file standalone
link-cli binaries for darwin-arm64, darwin-x64, linux-x64, and
windows-x64 every time a GitHub Release is published, plus a
manifest.json with sha256 checksums and download URLs.
Motivation: agent platforms and desktop apps that want to bundle
link-cli currently have to build their own binaries from the npm
package, which means owning a CI pipeline that compiles ink + viem +
update-notifier into a single executable. Shipping binaries upstream
lets downstream consumers pin against a manifest URL the same way they
pin any other vendored CLI (codex, claude-code, etc).
How it works:
- scripts/build-binary.ts runs bun build --compile against the existing
packages/cli/dist/cli.js entrypoint. A small plugin stubs two
optional deps that ink and update-notifier reference but that are
not needed at runtime in a bundled context (react-devtools-core only
loads when DEV=true; update-notifier is already external in tsup).
- scripts/generate-manifest.ts emits dist-bin/manifest.json with
version, generated_at, and per-target { file, sha256, url }.
- .github/workflows/release-binaries.yml triggers on
release.published (changesets/action publishes a GH release on every
npm release), builds all four targets in parallel inside a single
Ubuntu runner via Bun cross-compile, and attaches the binaries +
manifest to the release.
Verified locally: each binary runs --help, --version, and exercises
the viem-dependent mpp decode path successfully on darwin-arm64.
Sizes (minified): darwin-arm64 60 MB, darwin-x64 64 MB, linux-x64
101 MB, windows-x64 111 MB. Linux/Windows are heavier because Bun
ships more runtime polyfills for non-host targets.
17e5b3f to
23fdf0a
Compare
|
Force-pushed with the correct git author email so the commit links to my GitHub account. Going to sign the CLA now. |
| done | ||
|
|
||
| - name: Generate manifest | ||
| run: bun run scripts/generate-manifest.ts "${{ steps.tag.outputs.version }}" |
There was a problem hiding this comment.
doesn't this script need two params?
There was a problem hiding this comment.
Good catch — second arg was an optional URL override but nothing ever passed it, so it was dead flexibility. Removed in 3ee1633: script now takes one required version arg, with the GitHub releases URL hardcoded inline.
The script's second arg was an optional override for the GitHub releases download URL. Nothing in this workflow ever passes it, so the override is dead flexibility. Hardcoding the URL pattern simplifies the call site and matches the script's actual usage.
raubrey-stripe
left a comment
There was a problem hiding this comment.
Left some feedback @ja-818! Hopefully some quick hygiene.
Also, I'm curious how this will show up for your end customers, but because we are not signing our binaries in any way here in a sort of "apple approved" way, macOS Gatekeeper will quarantine the downloaded binary.. I'm not exactly sure how this will show up to end customers for you when bundled in Houston, but you can see the experience in another one of our products (purl) that bundles binaries: https://github.com/stripe/purl/releases/tag/v0.2.7
| run: | | ||
| TAG="${{ github.event.release.tag_name || github.event.inputs.release_tag }}" | ||
| # Strip the @stripe/link-cli@ prefix changesets uses, leaving "0.4.2" | ||
| VERSION="${TAG##*@}" | ||
| echo "tag=$TAG" >> "$GITHUB_OUTPUT" |
There was a problem hiding this comment.
run: steps interpolate ${{ }} expressions directly into shell commands. Per https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions#understanding-the-risk-of-script-injections, these should use intermediate env: vars to avoid shell injection via crafted tag names.
| run: | | |
| TAG="${{ github.event.release.tag_name || github.event.inputs.release_tag }}" | |
| # Strip the @stripe/link-cli@ prefix changesets uses, leaving "0.4.2" | |
| VERSION="${TAG##*@}" | |
| echo "tag=$TAG" >> "$GITHUB_OUTPUT" | |
| env: | |
| TAG: ${{ github.event.release.tag_name || github.event.inputs.release_tag }} | |
| run: | | |
| # Strip the @stripe/link-cli@ prefix changesets uses, leaving "0.4.2" | |
| VERSION="${TAG##*@}" | |
| echo "tag=$TAG" >> "$GITHUB_OUTPUT" |
| done | ||
|
|
||
| - name: Generate manifest | ||
| run: bun run scripts/generate-manifest.ts "${{ steps.tag.outputs.version }}" |
There was a problem hiding this comment.
Same feedback as above:
| run: bun run scripts/generate-manifest.ts "${{ steps.tag.outputs.version }}" | |
| env: | |
| VERSION: ${{ steps.tag.outputs.version }} | |
| run: bun run scripts/generate-manifest.ts "$VERSION" |
| env: | ||
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||
| run: | | ||
| gh release upload "${{ steps.tag.outputs.tag }}" \ |
There was a problem hiding this comment.
Same, replace with RELEASE_TAG: ${{ steps.tag.outputs.tag }} in env and "$RELEASE_TAG" here
| await Bun.build({ | ||
| entrypoints: ['./packages/cli/dist/cli.js'], | ||
| // @ts-expect-error compile is a Bun.build option but not in the public types yet | ||
| compile: { target: flag, outfile }, | ||
| plugins: [stubPlugin], | ||
| minify: true, | ||
| }); |
There was a problem hiding this comment.
Bun compiled executables auto-load .env from the current working directory. This means a malicious .env in the working directory could override LINK_API_BASE_URL or LINK_AUTH_BASE_URL to redirect API calls.
We need to disable this behavior; we could wrap the entry point here in a small wrapper that disables env pick up i.e. Can you give this a shot and confirm that a local .env cannot be used to set env variables?
entrypoints: ['./scripts/binary.ts'],
// ./scripts/binary.ts
// Disables Bun's automatic .env loading to prevent a malicious .env file in
process.env.BUN_CONFIG_NO_DOTENV = '1;
import '../packages/cli/dist/cli.js';
| - uses: actions/checkout@v4 | ||
| with: | ||
| ref: ${{ steps.tag.outputs.tag }} | ||
|
|
||
| - uses: oven-sh/setup-bun@v2 | ||
| with: | ||
| bun-version: '1.3.10' | ||
|
|
||
| - uses: pnpm/action-setup@v4 | ||
|
|
||
| - uses: actions/setup-node@v4 | ||
| with: | ||
| node-version: 20 | ||
| cache: pnpm |
There was a problem hiding this comment.
Can you pin these actions to a specific SHA? That's considered best practice in a workflow like this - I think it'd be
| - uses: actions/checkout@v4 | |
| with: | |
| ref: ${{ steps.tag.outputs.tag }} | |
| - uses: oven-sh/setup-bun@v2 | |
| with: | |
| bun-version: '1.3.10' | |
| - uses: pnpm/action-setup@v4 | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version: 20 | |
| cache: pnpm | |
| - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 | |
| with: | |
| ref: ${{ steps.tag.outputs.tag }} | |
| - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 | |
| with: | |
| bun-version: '1.3.10' | |
| - uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4 | |
| - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 | |
| with: | |
| node-version: 20 | |
| cache: pnpm |
Summary
Adds a release workflow that attaches single-file standalone
link-clibinaries to every GitHub Release, plus amanifest.jsonwith sha256 checksums and per-target download URLs. Targets:darwin-arm64,darwin-x64,linux-x64,windows-x64.Motivation
We're shipping Houston (a desktop platform for non-technical founders to run AI agents) with Link as a first-class payments surface. Houston bundles three CLIs today (
codex,composio,claude-code); for each we download a signed binary from upstream, sha256-verify, sign + notarize alongside our.app, and stage it intoContents/Resources/bin/. That works well because each upstream ships per-arch standalone binaries.link-clionly ships on npm. Bundling a non-Node binary today means owning abun build --compile(orpkg) pipeline per consumer, which means every consumer hitting the same packaging edges (thereact-devtools-coreresolution issue under ink,update-notifierexternal handling, viem's WASM + dynamic imports). Solving it once upstream is a much smaller surface than every agent platform doing it independently.Tracking issue with full context: #72.
What this PR does
scripts/build-binary.ts: invokesBun.buildagainst the existingpackages/cli/dist/cli.jsentrypoint, with a tiny in-process plugin that stubs two optional deps (react-devtools-coreis dynamically loaded by ink only whenDEV=true;update-notifieris already external intsup.config.ts). Output:dist-bin/link-cli-<target>[.exe].scripts/generate-manifest.ts: walksdist-bin/, sha256s every binary, emitsmanifest.jsonwithversion,generated_at, and per-target{ file, sha256, url }..github/workflows/release-binaries.yml: triggers onrelease.published(the eventchangesets/actionfires on every npm publish). On a single Ubuntu runner, builds all four targets via Bun cross-compile, generates the manifest, and uploads everything to the existing release viagh release upload.workflow_dispatchis provided for manual rebuilds against an existing tag.README.md: short "Standalone binaries" section under Installation pointing at the latest release + the manifest URL pattern..gitignore: addsdist-bin/.Verification
Run locally on macOS arm64 (Bun 1.3.10, Node 22, pnpm 10.32):
Result on
darwin-arm64host:pnpm turbo run build.dist-bin/link-cli-darwin-arm64 --helpand--versionwork.dist-bin/link-cli-darwin-arm64 mpp decode --challenge 'Payment id="ch_001", realm="merchant.example", method="stripe", intent="charge", request="..."'runs the viem-backed challenge decoder successfully (validation reaches the per-field "amount: missing" check, which is the expected behavior for an incomplete test challenge).CI gauntlet on this branch passes locally:
pnpm biome check .— clean (96 files)pnpm turbo run typecheck— passpnpm turbo run test— 100/100 passpnpm turbo run build— passNotes
1.3.10in the workflow to keep release outputs reproducible. Happy to change that tolatestor whatever your team prefers.// @ts-expect-erroron one line because thecompileoption is supported byBun.buildat runtime but not yet in the public TypeScript types.release.yml— the existing changesets-driven flow is unchanged. This new workflow is fully additive and runs after the release event the existing flow already produces.Future work (not in this PR)
mpp decodeso the static bundle is smaller for non-MPP users (mentioned in the tracking issue).🤖 Co-authored with Claude Code.